Displaying Rich Text on Android Using The Contentful SDK
Contentful is a CMS that allows you to easily distribute content to all types of platforms including websites, iOS and android apps. It provides you with an easy-to-use web app where you can manage your content and provide it to all your platforms. The content that you want to provide can be one of many types, each with their own use.
Rich Text
One of the content types is Rich Text. This allows you to format your text to give it more structure, add hyperlinks to external resources or even insert a code block or an image. This can be very useful for content where a lot of text is present, like a blogpost or an article.
If we want to display this rich text in the way that we input it into the web app, we'll need to have a renderer that is capable of handling all the different styles. Today we'll take a look at how the Contentful Android SDK provides this data, and how we can render it nicely.
Generating Some Sample Content
Before we can access our content in the android app, we will have to provide it. We create a new Blogpost content model. Our model will have a Rich Text field called body that we can use to write our blogposts. Then we go over to the content tab and write a little blogpost.
Finally we publish the post, and our sample content is done.
Reading the data in Android
To receive our sample content in android we will be using the official Contentful Android SDK.
Getting our content is simple. First, we initiate the Content Delivery API Client. Then we can use said client to retrieve our sample blogpost. We'll be using rxAndroid to help us with making asynchronous network calls.
// Building the CDAClient
val client = CDAClient.builder()
.setSpace("{space-key-goes-here}")
.setToken("{access-token-goes-here}")
.build()
// Retrieving the sample blogpost
client.observe(CDAEntry::class.java)
.one("{entry-id-goes-here}")
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe({ entry ->
val body = entry.getField<CDARichDocument>("body")
}) {
Log.e("Contentful Error", it.toString())
}
If we take a look at the entry, it shows that it has our body field that contains a CDARichDocument
object. Reading out the body using the getField
method shows that it contains a list of objects for all our elements. We can clearly see our blogpost structure with headings, paragraphs, lists, hyperlinks, quotes and images, all in their own object type.
Rendering the Rich Text
Now that we have our data, we'll have to render it. We could write our own mappers to transform the data into renderable views, however that will be very time consuming. Luckily for us, the Contentful team themselves are working on a Rich Text Renderer for android.
Attention: At the time of writing, this library is still in beta. The developers did recently say that they are trying to get the library production ready in the coming weeks.
With this library we create a processor that allows us to process a CDARichDocument
node. We can render to android in two different ways: through spannables or through custom views. We'll take a look at both ways.
Spannables
To work with spannables we'll use the sequenceProcessor
. This will create a SpannableStringBuilder
that we can then use on a TextView
.
client.observe(CDAEntry::class.java)
.one("{entry-id-goes-here}")
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe({entry ->
val body = entry.getField<CDARichDocument>("body")
val context = AndroidContext(this)
val sequenceProcessor = AndroidProcessor.creatingCharSequences()
val sequenceResult = sequenceProcessor.process(context, body)
textView.setText(sequenceResult, TextView.BufferType.SPANNABLE)
}) {
Log.e("Error", it.toString())
}
And this is the result:
We can immediately see that the result is not what we desired. The most obvious issues are:
- Everything is placed on one line, there are no linebreaks at all places that we expect.
- The image is not rendered at all.
- While the hyperlink is styled like a link, clicking it does nothing.
- Quotes aren't rendered as expected.
- The horizontal ruler is not the full width of the screen.
With all these issues, this method is far from ideal.
Custom Views
The library also has a viewProcessor
that generates a LinearLayout
full of custom views for all the elements. Let's try out that one:
client.observe(CDAEntry::class.java)
.one("{entry-id-goes-here}")
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe({entry ->
val body = entry.getField<CDARichDocument>("body")
val context = AndroidContext(this)
val viewProcessor = AndroidProcessor.creatingNativeViews()
val viewResult = viewProcessor.process(context, body)
viewResult?.setPadding(30, 30, 30, 30)
container.addView(viewResult)
}) {
Log.e("Error", it.toString())
}
This time around, the results look way more promising:
There are still a few issues though:
- The image is not rendered at all.
- The hyperlink works but is rendered in a very unique way.
The hyperlink style is an interesting one. let's dig through the code a bit to check how the renderer gets to this result.
Analysing the hyperlink code
In the AndroidProcessor
code we see that the viewProcessor
uses a NativeViewsRendererProvider
.
// AndroidProcessor.java
public class AndroidProcessor<T> extends Processor<AndroidContext, T> {
...
public static AndroidProcessor<View> creatingNativeViews() {
final AndroidProcessor<View> processor = new AndroidProcessor<>();
new NativeViewsRendererProvider().provide(processor);
return processor;
}
...
}
Looking in this NativeViewsRendererProvider
we see that it calls a number of different renderers, including one for hyperlinks.
// NativeViewsRendererProvider.java
public class NativeViewsRendererProvider {
public void provide(@Nonnull AndroidProcessor<View> processor) {
processor.addRenderer(new TextRenderer(processor));
processor.addRenderer(new HorizontalRuleRenderer(processor));
processor.addRenderer(new ListRenderer(processor, new BulletDecorator()));
processor.addRenderer(new ListRenderer(processor,
new NumbersDecorator(),
new UpperCaseCharacterDecorator(),
new LowerCaseRomanNumeralsDecorator(),
new LowerCaseCharacterDecorator(),
new LowerCaseCharacterDecorator(),
new UpperCaseRomanNumeralsDecorator()
));
processor.addRenderer(new HyperLinkRenderer(processor));
processor.addRenderer(new QuoteRenderer(processor));
processor.addRenderer(new BlockRenderer(processor));
}
}
Looking at the HyperLinkRenderer
we can see that it adds an onClick
listener to handle the actual linking, and we also see that it inflates R.layout.rich_hyperlink_layout
.
<!--rich_hyperlink_layout.xml-->
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#40808080"
>
<ImageView
android:id="@+id/rich_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:padding="4dp"
android:src="@android:drawable/ic_menu_share"
/>
<LinearLayout
android:id="@id/rich_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_toEndOf="@id/rich_image"
android:gravity="center"
android:orientation="vertical"
/>
</RelativeLayout>
We can clearly see that this layout matches the hyperlink style in the app. If you don't like the way this or any other elements are styled, don't worry! The library has a way to add or override a style and change it to your liking.
Adding or overriding renderers
The processor has two methods to alter the renderers: .addRenderer(…,…)
and .overrideRenderer(…,…)
. Both these methods have two parameters that are each lambda functions.
The first lambda is called the checker. This function gives you the node and expects a boolean in return. It allows you to check if this renderer handles this node (e.g. is this node a hyperlink? or is this node an image?)
The second lambda is the actual renderer. It gives you the node and expects a view in return. This one will only be called if the checker returns true.
The difference between addRenderer
and overrideRenderer
is when they are called. addRenderer
will be added to the end of the list, whereas overrideRenderer
will be added to the start. Each node will only trigger the first checker that matches.
Adding a custom image renderer
Let's try out these functions by writing a custom renderer to render the images. As we saw in the body node, an image is a CDARichEmbeddedBlock
containing a CDAAsset
. So we need to check for these types in our checker. To fetch and show the actual image we will use Picasso.
viewProcessor.overrideRenderer(
// Checker
{ _, node -> node is CDARichEmbeddedBlock && node.data is CDAAsset },
// Renderer
{ _, node ->
val data = (node as CDARichEmbeddedBlock).data as CDAAsset
val imageview = ImageView(this).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
Picasso.get().load("https:" + data.url()).into(imageview)
imageview
}
)
Please note that we useoverrideRenderer
and notaddRenderer
. This is because our image node will trigger the checker of the defaultBlockRenderer
. As a result, our renderer would never be called if we were to useaddRenderer
.
And if we look in our app we see that now it will also render our image:
And that's it!
We can now render contentful rich text fields in android and know how to add or override elements if we want to style them differently.